/**
 * Copyright (c) 2007, Blue Hole Software. All rights reserved.
 * Code licensed under the Apache 2.0 License:
 * http://www.apache.org/licenses/
 */
package org.bhf.providers.security.authentication;

import com.google.inject.Inject;
import org.bhf.db.txn.Transaction;
import org.bhf.db.txn.TransactionPolicy;
import org.bhf.facilities.FacilityConfiguration;
import org.bhf.facilities.Project;
import org.bhf.security.authentication.AuthenticationFacility;
import org.bhf.security.authentication.AuthenticationFacilityException;
import org.bhf.security.authentication.InvalidPasswordException;
import org.bhf.security.authentication.LoginDisabledException;
import org.bhf.security.authentication.LoginHoldException;
import org.bhf.security.authentication.LoginNotFoundException;
import org.bhf.security.common.EMail;
import org.bhf.security.common.FullName;
import org.bhf.security.common.Group;
import org.bhf.security.common.LoginID;
import org.bhf.security.common.NickName;
import org.bhf.security.common.UserID;
import org.bhf.security.util.Crypto;

import javax.persistence.EntityManager;
import javax.persistence.NoResultException;
import javax.security.auth.Subject;
import javax.security.auth.login.CredentialExpiredException;
import javax.security.auth.login.FailedLoginException;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;

/**
 *
 */
public class Provider implements AuthenticationFacility
{
    private final SessionTracker    sessionTracker = new SessionTracker();
    private final Config            config;

    @Inject
    private com.google.inject.Provider<EntityManager> provider;

    //
    // Configuration
    //

    // For testing
    Config  getConfig() { return config; }

    public static class Config extends FacilityConfiguration
    {
        private int     sessionLengthInMinutes  = 5;
        private int     strikeOutCount          = 3;
        private int     strikeOutHoldInSeconds  = 5 * 60;
        private int     minimumPasswordLength   = 6;
        private String  passwordPattern         = "[a-zA-Z0-9_]*";
        private int     passwordValidityInDays  = 100 * 365;

        /**
         * Return the number of seconds to hold a login in response to a strike out
         * @return the number of seconds to hold a login in response to a strike out
         */
        public int          getStrikeOutHoldInSeconds()
        {
            return strikeOutHoldInSeconds;
        }

        public void         setStrikeOutHoldInSeconds( final int strikeOutHoldInSeconds )
        {
            this.strikeOutHoldInSeconds = strikeOutHoldInSeconds;
        }

        /**
         * Time window for measuring strike outs. The user is disabled if more than N successive login failures occur
         * with this time window. Defaults to 5 minutes. (This has nothing to do with HTTP sessions.)
         * @return Time window for measuring strike outs.
         */
        public int          getSessionLengthInMinutes()
        {
            return sessionLengthInMinutes;
        }

        public void         setSessionLengthInMinutes( final int sessionLengthInMinutes )
        {
            this.sessionLengthInMinutes = sessionLengthInMinutes;
        }

        /**
         * Strike out limit, N. The user is disabled if more than N successive login failures occur
         * with the session time window. Defaults to 5 minutes.
         * @return The Strike out limit.
         */
        public int          getStrikeOutCount()
        {
            return strikeOutCount;
        }

        public void         setStrikeOutCount( final int strikeOutCount )
        {
            this.strikeOutCount = strikeOutCount;
        }

        /**
         * Minimum acceptable password length when changin a password. Defaults to 6.
         * @return Minimum acceptable password length.
         */
        public int          getMinimumPasswordLength()
        {
            return minimumPasswordLength;
        }

        public void         setMinimumPasswordLength( final int minimumPasswordLength )
        {
            this.minimumPasswordLength = minimumPasswordLength;
        }

        /**
         * Regex defining a valid password. The default is alpha-numeric with underscores.
         * @return Regex defining a valid password.
         */
        public String       getPasswordPattern()
        {
            return passwordPattern;
        }

        public void         setPasswordPattern( final String passwordPattern )
        {
            this.passwordPattern = passwordPattern;
        }

        /**
         * The length of time before a password becomes invalid. Defaults to 100 years (36500 days, actually).
         * @return The length of time before a password becomes invalid.
         */
        public int          getPasswordValidityInDays()
        {
            return passwordValidityInDays;
        }

        public void         setPasswordValidityInDays( final int passwordValidityInDays )
        {
            this.passwordValidityInDays = passwordValidityInDays;
        }
    }

    @Inject
    public  Provider( final Config config )
    {
        this.config = config;
        this.config.configure( null, Provider.class, Provider.Config.class );
    }

    //
    // AuthenticationFacility contract
    //

    /**
     *@inheritDoc
     */
    public PasswordEncoding[]   getSupportedPasswordFormats()
    {
        return new PasswordEncoding[]
            {
                PasswordEncoding.PLAINTEXT,
                PasswordEncoding.BASE64MD5,
                PasswordEncoding.BASE64MD5x2,
                PasswordEncoding.NONE
            };
    }

    /**
     *@inheritDoc
     */
    @Transaction( unit = "authentication", rollbackOnException = false )
    public Subject      authenticate(
                            final LoginID           loginID,
                            final char[]            password,
                            final PasswordEncoding  passwordFormat,
                            final Project           project,
                            final boolean           log
                        )
        throws FailedLoginException, CredentialExpiredException, LoginDisabledException, LoginNotFoundException, LoginHoldException, AuthenticationFacilityException
    {
        if( loginID == null )
            throw new IllegalArgumentException( "loginID == null" );
        if( password == null && passwordFormat != PasswordEncoding.NONE )
            throw new IllegalArgumentException( "password == null" );

        try
        {
            try
            {
                // authenticate
                final User          user    = User.findByLoginID( provider.get(), loginID.getID() );
                final Subject       subject = new Subject();

                populateSubject( subject, user, project );

                // ensure User has at least one Role in Project
                if( org.bhf.security.common.Role.getRoles( subject ).length == 0 )
                    throw new FailedLoginException();

                // Check password, expiration, etc.
                if( passwordFormat != PasswordEncoding.NONE )
                {
                    checkAuthenticate( user, password, passwordFormat, log );

                    // check for account expiration
                    checkPasswordExpiration( user );

                    // update date last login
                    user.setLastLogin( new Date() );
                }

                return subject;
            }
            catch( NoResultException nre )
            {
                throw new LoginNotFoundException( Localize.noSuchUser( loginID ) );
            }
        }
        catch( final AuthenticationFacilityException afe )
        {
            Package.log.log( Level.SEVERE, "DataBaseProvider", afe );
            throw afe;
        }
    }

    /**
     *@inheritDoc
     */
    @Transaction( unit = "authentication" )
    public void         logout( final LoginID loginID )
    {
        sessionTracker.getSession( loginID, 0 ).logOut();
    }

    /**
     *@inheritDoc
     */
    @Transaction( unit = "authentication" )
    public String       getSigningPassword( final LoginID loginID )
        throws LoginNotFoundException, AuthenticationFacilityException
    {
        if( loginID == null )
            throw new IllegalArgumentException( "loginID == null" );

        try
        {
            return User.findByLoginID( provider.get(), loginID.getID() ).getPasswordHash();
        }
        catch( NoResultException nre )
        {
            throw new LoginNotFoundException( Localize.noSuchUser( loginID ) );
        }
    }

    /**
     *@inheritDoc
     */
    @Transaction( unit = "authentication" )
    public Project[]    getProjects( final LoginID loginID )
        throws LoginNotFoundException, AuthenticationFacilityException
    {
        if( loginID == null )
            throw new IllegalArgumentException( "loginID == null" );

        try
        {
            final User          user        = User.findByLoginID( provider.get(), loginID.getID() );
            final Set<Project>  projects    = new HashSet<Project>();

            enumerateProjects( user.getRoleMap(), projects );

            return projects.toArray( new Project[ projects.size() ] );
        }
        catch( NoResultException nre )
        {
            throw new LoginNotFoundException( Localize.noSuchUser( loginID ) );
        }
    }

    /**
     *@inheritDoc
     */
    @Transaction( unit = "authentication" )
    public void         changePassword(
                            final LoginID   loginID,
                            final char[]    plainTextPassword
                        )
        throws InvalidPasswordException, LoginNotFoundException, AuthenticationFacilityException
    {
        try
        {
            if( loginID == null )
                throw new IllegalArgumentException( "loginID == null" );
            if( plainTextPassword == null )
                throw new IllegalArgumentException( "plainTextPassword == null" );

            final Date                  currentDate         = new Date();
            final User                  user                = User.findByLoginID( provider.get(), loginID.getID() );
            final int                   passwordMinLength   = config.getMinimumPasswordLength();
            final String                passwordPassword    = config.getPasswordPattern();
            final int                   daysToExpire        = config.getPasswordValidityInDays();
            final Calendar              expirationDate      = Calendar.getInstance();
            final String                encodedPassword     = Crypto.encodeMD5Base64( new String( plainTextPassword ) );

            if( plainTextPassword.length < passwordMinLength )
                throw new InvalidPasswordException( Localize.passwordMinLength( passwordMinLength ) );

            if( !( new String( plainTextPassword ).matches( passwordPassword ) ) )
                throw new InvalidPasswordException( Localize.passwordPattern( passwordPassword ) );

            if( user.getPasswordHash().equals( encodedPassword ) )
                throw new InvalidPasswordException( Localize.samePassword() );

            expirationDate.setTimeInMillis( currentDate.getTime() );
            expirationDate.add( Calendar.DAY_OF_MONTH, daysToExpire );

            user.setPasswordHash( encodedPassword );
            user.setExpires( expirationDate.getTime() );
            user.setChanged( currentDate );
        }
        catch( final NoResultException lnfe )
        {
            throw new LoginNotFoundException( Localize.noSuchUser( loginID ) );
        }
        catch( final InvalidPasswordException ipe )
        {
            throw ipe;
        }
        catch( final Exception e )
        {
            final AuthenticationFacilityException afe = new AuthenticationFacilityException();
            afe.initCause( e );
            throw afe;
        }
    }

    /**
     *@inheritDoc
     */
    @Transaction( unit = "authentication" )
    public void         disable( LoginID loginID )
        throws LoginNotFoundException, AuthenticationFacilityException
    {
        setDisabled( loginID, true );
    }

    /**
     *@inheritDoc
     */
    @Transaction( unit = "authentication" )
    public void         enable( LoginID loginID )
        throws LoginNotFoundException, AuthenticationFacilityException
    {
        setDisabled( loginID, false );
    }

    //
    // Private
    //

    /**
     * @inheritDoc
     * @exclude
     */
    private void        populateSubject( final Subject subject, final User user, final Project project )
    {
        // Principals
        {
            final Set<org.bhf.security.common.Role> roles = new HashSet<org.bhf.security.common.Role>();
            filterRolesByProject( user.getRoleMap(), project, roles );
            subject.getPrincipals().addAll( roles );
            subject.getPrincipals().add( new UserID( user.getId() ) );
        }

        // Credentials
        subject.getPublicCredentials().add( new LoginID( user.getLoginID() ) );

        // Info
        subject.getPublicCredentials().add( new FullName( user.getFirstName() + " " + user.getLastName() ) );
        subject.getPublicCredentials().add( new NickName( user.getNickName() ) );
        subject.getPublicCredentials().add( new EMail( user.getFirstName() + " " + user.getLastName() ) );
        subject.getPublicCredentials().add( new Group( user.getGroup() ) );
    }

    private void        filterRolesByProject( final Set<RoleMap> roles, final Project project, final Set<org.bhf.security.common.Role> projectRoles )
    {
        if( roles != null )
        {
            for( final RoleMap rm : roles )
            {
                if( rm.getProject().equals( project.getName() ) )
                {
                    final Role                  r           = rm.getRole();
                    final Map<String,String[]>  parameters  = new HashMap<String,String[]>();

                    for( final Map.Entry<String,String[]> e : rm.getParameters().entrySet() )
                        parameters.put( e.getKey(), e.getValue() );

                    projectRoles.add( new org.bhf.security.common.Role( r.getName(), parameters ) );
                    filterRolesByProject( r.getRoleMap(), project, projectRoles );
                }
            }
        }
    }

    private void        enumerateProjects( final Set<RoleMap> roles, final Set<Project> projects )
    {
        if( roles != null )
        {
            for( final RoleMap rm : roles )
            {
                projects.add( new Project( rm.getProject() ) );
                enumerateProjects( rm.getRole().getRoleMap(), projects );
            }
        }
    }


    /**
     * Authenticates the specified login ID and password.
     *
     * @param user The user entity.  Must not be <code>null</code>.
     * @param password the plain textpassword.  Must not be <code>null</code>.
     * @param passwordFormat the format of the password.
     * @param log <code>true</code> to indicate that logging should occur.
     *
     * @throws FailedLoginException If authentication fails.
     * @throws LoginDisabledException if the Login account has been disabled.
     * @throws LoginNotFoundException if the login ID cannot be found.
     * @throws LoginHoldException Account on hold
     * @throws AuthenticationFacilityException An internal error
     */
    private void                checkAuthenticate(
                                    final User              user,
                                    final char[]            password,
                                    final PasswordEncoding  passwordFormat,
                                    final boolean           log
                                )
        throws LoginDisabledException, AuthenticationFacilityException, LoginNotFoundException, LoginHoldException, FailedLoginException
    {
        final SessionTracker.Session    session             = sessionTracker.getSession( new LoginID( user.getLoginID() ), config.getSessionLengthInMinutes() * 60L * 1000L );
        final int                       strikeOutCountLimit = config.getStrikeOutCount();

        // Account disabled?
        if( user.isDisabled() )
        {
            // Reset session so that they can log in when the account is reenabled
            session.logOut();

            throw new LoginDisabledException( Localize.failedBecauseDisabled() );
        }

        // On hold?
        if( user.isOnHold() )
        {
            throw new LoginHoldException( Localize.accountOnHold( new LoginID( user.getLoginID() ), (config.getStrikeOutHoldInSeconds() + 59) / 60 ) );
        }

        // Correct password. In the DB, the passwords is hashed once.
        {
            final String hashedDataBasePassword;
            final String hashedInputPassword;


            switch( passwordFormat )
            {
                case PLAINTEXT:
                    hashedDataBasePassword  = user.getPasswordHash();
                    hashedInputPassword     = Crypto.encodeMD5Base64( new String( password ) );
                    break;
                case BASE64MD5:
                    hashedDataBasePassword  = user.getPasswordHash();
                    hashedInputPassword     = new String( password );
                    break;
                case BASE64MD5x2:
                    hashedDataBasePassword  = Crypto.encodeMD5Base64( user.getPasswordHash() );
                    hashedInputPassword     = new String( password );
                    break;
                default:
                    throw new RuntimeException();
            }

            if( !hashedDataBasePassword.equals( hashedInputPassword ) )
            {
                if( log )
                {
                    final AuthenticationLog event = new AuthenticationLog();
                    event.setUserID( user.getId() );
                    event.setEvent( LogEvent.FAILED );
                    log( event );
                }

                // This is really the only valid failure case for tries-and-out - do it here
                // N strikes and you're out logic
                if( session.logFailure() >= strikeOutCountLimit )
                {
                    user.placeOnHoldFor( config.getStrikeOutHoldInSeconds() );
                    throw new LoginHoldException( Localize.accountOnHold( new LoginID( user.getLoginID() ), (config.getStrikeOutHoldInSeconds() + 59) / 60 ) );
                }

                throw new FailedLoginException( Localize.failedBecauseBadPassword() );
            }
        }

        // Log sucess
        // To handle session-less designs, such as service orientation, we only log the
        // success that cause the session to be created.
        if( log )
        {
            final AuthenticationLog event = new AuthenticationLog();
            event.setUserID( user.getId() );
            event.setEvent( LogEvent.SUCCEEDED );
            log( event );
        }
    }

    /**
     * Checks if the password for the specified Login ID has expired.
     * @param user User credentials
     * @throws CredentialExpiredException if the password has expired.
     */
    private void        checkPasswordExpiration( final User user )
        throws CredentialExpiredException
    {
        final Date passwordExpiration   = user.getExpires();
        final Date now                  = new Date();

        if( ( passwordExpiration != null ) && ( passwordExpiration.before( now ) ) )
        {
            throw new CredentialExpiredException();
        }
    }

    /**
     * Sets the disabled status of the specified Login ID.
     *
     * @param loginID the Login ID.  Must not be <code>null</code>.
     * @param disabled the disabled status.  Set to <code>true</code>
     *      to indicate that the Login ID should be disabled.
     *
     * @throws LoginNotFoundException if the Login ID cannot be found.
     * @throws AuthenticationFacilityException if an error occurs setting
     *      the disabled status.
     */
    private void        setDisabled(
                            final LoginID loginID,
                            final boolean disabled
                        )
        throws LoginNotFoundException, AuthenticationFacilityException
    {
        if( loginID == null )
            throw new IllegalArgumentException( "loginID == null" );

        try
        {
            // Do
            final User user = User.findByLoginID( provider.get(), loginID.getID() );
            user.setDisabled( disabled );

            // Log
            if( disabled )
            {
                final AuthenticationLog event = new AuthenticationLog();
                event.setEvent( LogEvent.DISABLED );
                event.setUserID( user.getId() );
                log( event );
            }
        }
        catch( NoResultException nre )
        {
            throw new LoginNotFoundException( Localize.noSuchUser( loginID ) );
        }
    }

    /**
     * Log this event. So that failures, which often cause a rollback of the current transaction, may be logged,
     * logging is done in its own transaction.
     * @param authenticationLog Log record
     */
    @Transaction( unit = "authentication", policy = TransactionPolicy.REQUIRES_NEW )
    private void        log( final AuthenticationLog authenticationLog )
    {
        provider.get().persist( authenticationLog );
    }
}

